#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2007-2008 Christopher Lenz
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution.

"""Implementation of a view server for functions written in Python."""

from codecs import BOM_UTF8
import logging
import os
import sys
import traceback
import imp
import inspect
import traceback
from collections import OrderedDict, Callable

from couchdb import json

__all__ = ['main', 'run']
__docformat__ = 'restructuredtext en'

log = logging.getLogger('couchdb.view')
EXECUTION_ERROR = False

def run(input=sys.stdin, output=sys.stdout):
    r"""CouchDB view function handler implementation for Python.

    :param input: the readable file-like object to read input from
    :param output: the writable file-like object to write output to
    """
    global EXECUTION_ERROR
    EXECUTION_ERROR = False
    functions = []

    def _writejson(obj):
        obj = json.encode(obj)
        if isinstance(obj, unicode):
            obj = obj.encode('utf-8')
        output.write(obj)
        output.write('\n')
        output.flush()

    def _log(message):
        if not isinstance(message, basestring):
            message = json.encode(message)
        _writejson(['log', message])

    def _load(module, callable):
        '''Provides dynamic loading of functions from a given package/module path'''
        
        parts = module.split('.')
        last_path = None
        loaded = None
        for index in range(len(parts)):
            part = parts[index]
            modulefile = None
            try:
                modulefile, pathname, description = imp.find_module(part, last_path)
                loaded = imp.load_module(part, modulefile, pathname, description)
                if hasattr(loaded, '__path__'):
                    last_path = loaded.__path__
            finally:
                if hasattr(modulefile, 'close'):
                    modulefile.close()
        inspect.currentframe().f_back.f_locals['__couch__'] = getattr(loaded, callable)
    
    def reset(config=None):
        del functions[:]
        return True

    def add_fun(source):
        source = BOM_UTF8 + source.encode('utf-8')
        
        # Create scope with a provided context
        scope = OrderedDict()
        context = {'log': _log, 'load': _load}
        scope.update(context)
        
        # Execute view source code
        try:
            exec compile(source, '<couchdb_map_code>', 'exec') in scope
        except:
            details = 'map_compilation_error: \n{0}'.format(filter_exception(traceback.format_exc()))
            log.error(details)
            # I couldn't find any way to properly return an error; the
            #   documented method did not work out.  I'm only returning here for
            #   the sake of the test suites.
            return details
        
        # Find the last callable
        callables = [value for key, value in scope.items() if isinstance(value, Callable) and key not in context]
        if not callables:
            details = 'map_compilation_error: no callable was specified in the map source code'
            log.error(details)
            return details
        else:
            functions.append(callables[-1])
        
        # All succeeded
        return True
    
    def map_doc(doc):
        global EXECUTION_ERROR
        
        results = []
        
        if not EXECUTION_ERROR:  # Skip execution if a previous execution threw an error (keeps logs clean)
            for exec_function in functions:
                try:
                    results.append([[key, value] for key, value in exec_function(doc)])
                except:
                    exception = 'map_runtime_error: \n{0}'.format(filter_exception(traceback.format_exc()))
                    log.error(exception)
                    _log(exception)
                    EXECUTION_ERROR = True
                    results.append([])
                    break
        return results

    def reduce(*cmd, **kwargs):
        global EXECUTION_ERROR
        
        source = BOM_UTF8 + cmd[0][0].encode('utf-8')
        args = cmd[1]
        
        # Create scope with a provided context
        scope = OrderedDict()
        context = {'log': _log, 'load': _load}
        scope.update(context)
        
        # Execute view source code
        try:
            exec compile(source, '<couchdb_reduce_code>', 'exec') in scope
        except:
            details = 'reduce_compilation_error: \n{0}'.format(filter_exception(traceback.format_exc()))
            log.error(details)
            return details
        
        # Find the last callable
        callables = [value for key, value in scope.items() if isinstance(value, Callable) and key not in context]
        if not callables:
            details = 'reduce_compilation_error: no callable was specified in the reduce source code'
            log.error(details)
            return details
        
        exec_function = callables[-1]
        
        # Perform reduction
        rereduce = kwargs.get('rereduce', False)
        results = []
        if not EXECUTION_ERROR:  # Skip on error, as explained above
            if rereduce:
                keys = None
                vals = args
            else:
                keys, vals = zip(*args)
            try:
                if exec_function.func_code.co_argcount == 3:
                    results = exec_function(keys, vals, rereduce)
                else:
                    results = exec_function(keys, vals)
            except:
                details = 'reduce_runtime_error: \n{0}'.format(filter_exception(traceback.format_exc()))
                log.error(details)
                _log(details)
                EXECUTION_ERROR = True
                return details
        
        # All's well
        return [True, [results]]

    def rereduce(*cmd):
        # Note: weird kwargs is for Python 2.5 compat
        return reduce(*cmd, **{'rereduce': True})

    handlers = {'reset': reset, 'add_fun': add_fun, 'map_doc': map_doc,
                'reduce': reduce, 'rereduce': rereduce}

    try:
        while True:
            line = input.readline()
            if not line:
                break
            try:
                cmd = json.decode(line)
                log.debug('Processing %r', line)
            except ValueError, e:
                log.error('general_error: \n{0}'.format(filter_exception(traceback.format_exc(), True)))
                return 1
            else:
                retval = handlers[cmd[0]](*cmd[1:])
                log.debug('Returning  %r', json.encode(retval))
                _writejson(retval)
    except KeyboardInterrupt:
        return 0
    except Exception, e:
        log.error('general_error: \n{0}'.format(filter_exception(traceback.format_exc(), True)))
        return 1


_VERSION = """%(name)s - CouchDB Python %(version)s

Copyright (C) 2007 Christopher Lenz <cmlenz@gmx.de>.
"""

_HELP = """Usage: %(name)s [OPTION]

The %(name)s command runs the CouchDB Python view server.

The exit status is 0 for success or 1 for failure.

Options:

  --version             display version information and exit
  -h, --help            display a short help message and exit
  --json-module=<name>  set the JSON module to use ('simplejson', 'cjson',
                        or 'json' are supported)
  --log-file=<file>     name of the file to write log messages to, or '-' to
                        enable logging to the standard error stream
  --debug               enable debug logging; requires --log-file to be
                        specified

Report bugs via the web at <http://code.google.com/p/couchdb-python>.
"""

def filter_exception(tb, keep=False):
    '''Returns a traceback indented and filtered of most statements involved
        with the dynamic execution of code.'''
    
    indentation = ' ' * 4
    lines = tb.splitlines()
    if keep:
        filtered = lines
    else:
        filtered = []
        outlawed = (
            'exec compile',
            'exec_function',
            'imp.find_module',
            'imp.load_module',
            "f_locals['__couch__']"
        )
        for line in lines:
            dangerous = [outlaw for outlaw in outlawed if outlaw in line]
            if dangerous:
                filtered.pop()
            else:
                filtered.append(line)
    return indentation + ('\n{0}'.format(indentation)).join(filtered)

def main():
    """Command-line entry point for running the view server."""
    import getopt
    from couchdb import __version__ as VERSION

    try:
        option_list, argument_list = getopt.gnu_getopt(
            sys.argv[1:], 'h',
            ['version', 'help', 'json-module=', 'debug', 'log-file=']
        )

        message = None
        for option, value in option_list:
            if option in ('--version'):
                message = _VERSION % dict(name=os.path.basename(sys.argv[0]),
                                      version=VERSION)
            elif option in ('-h', '--help'):
                message = _HELP % dict(name=os.path.basename(sys.argv[0]))
            elif option in ('--json-module'):
                json.use(module=value)
            elif option in ('--debug'):
                log.setLevel(logging.DEBUG)
            elif option in ('--log-file'):
                if value == '-':
                    handler = logging.StreamHandler(sys.stderr)
                    handler.setFormatter(logging.Formatter(
                        ' -> [%(levelname)s] %(message)s'
                    ))
                else:
                    handler = logging.FileHandler(value)
                    handler.setFormatter(logging.Formatter(
                        '[%(asctime)s] [%(levelname)s] %(message)s'
                    ))
                log.addHandler(handler)
        if message:
            sys.stdout.write(message)
            sys.stdout.flush()
            sys.exit(0)

    except getopt.GetoptError, error:
        message = '%s\n\nTry `%s --help` for more information.\n' % (
            str(error), os.path.basename(sys.argv[0])
        )
        sys.stderr.write(message)
        sys.stderr.flush()
        sys.exit(1)
    run()


if __name__ == '__main__':
    main()
